iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI 自動化

協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的系列 第 13 篇

Day 13:用 Python 實作 ReAct:完成第一個會使用 MCP 的 Agent

  • 分享至 

  • xImage
  •  

昨天讓模型第一次自己選 Tool。

流程只走到:

問題
↓
模型選擇 Tool
↓
Action
↓
Observation

拿到 Observation 後就停了。

今天只多一件事:

把 Tool 的結果放回對話,再問模型下一步。

然後重複這件事,直到模型回答,或 Host 決定該停了。

看起來就是一個迴圈。

實際上也真的只是一個迴圈。

真正麻煩的不是 for 怎麼寫,而是:

什麼情況才算真的完成?

一、對話紀錄就是 Agent 最基本的狀態

Agent 一開始只有兩則訊息:

system
user

模型回傳後,先把 Assistant 訊息保留下來。

如果它要求 Tool,就真的執行,接著再把 Observation 放進訊息列表:

messages.append(msg)

for call in msg.tool_calls:
    obs = await tools.call(
        call.name,
        call.arguments,
    )

    observations.append(obs)

    messages.append(
        ChatMessage(
            role="tool",
            tool_name=call.name,
            content=(
                ("ERROR: " if obs.is_error else "")
                + obs.output
            ),
        )
    )

下一輪模型拿到的就不再只有原始問題,而是:

使用者問了什麼
↓
上一輪模型要求了什麼 Tool
↓
Tool 實際回了什麼

然後再決定下一步。

這就形成最小的 Agent Loop:

Question
↓
Model
↓
Action
↓
Observation
↓
Model
↓
Action / Answer

有一個細節不要省。

模型提出 Tool Call 的那則 Assistant 訊息也要留下來。

不要只把 Tool Result 硬塞回去,不然對話裡會少掉:

這份 Observation 到底是在回應哪一次工具要求?

在這份實作裡,tool_name 也會跟著 Tool 訊息保存,讓後面的流程知道結果來自哪個工具。

二、Agent 一定要知道怎麼停

如果只是:

while True:
    ...

那不是 Agent。

那是事故的前置作業。

今天先定義幾種明確的停止原因。

answered

模型不再要求 Tool,而且回傳了正常文字答案:

stop_reason = answered

這才代表流程正常完成。

max_steps

Agent 已經跑到最大迴圈數:

stop_reason = max_steps

這代表:

還沒有正常完成,但 Host 不允許它繼續跑。

tool_budget

模型下一輪要求的 Tool 數量會超過剩餘額度:

stop_reason = tool_budget

所以 Host 直接停止,不執行這批呼叫。

empty_answer

模型沒有 Tool Call,也沒有有效答案:

stop_reason = empty_answer

這也不能算正常完成。

最後結果就不只是:

"完成了"

而是會保留:

answer
stop_reason
observations
steps
tool_calls

這些資訊之後交給 Supervisor 或其他 Agent 時會很重要。

因為:

正常完成

跟:

跑到限制被迫停止

完全是兩回事。

三、Tool Budget 要由 Host 算

假設 Tool 預算只剩:

2 次

結果模型一次提出:

read_file(...)
read_file(...)
list_files(...)

共三個 Tool Call。

這時不能執行前兩個,再把第三個丟掉。

今天的做法是:

先檢查整批 Tool Calls
↓
超出剩餘 Budget
↓
整批不執行

因為執行一半,狀態會變得很難解釋。

今天的 Tool 都是唯讀,所以看起來還好。

但假設以後變成:

建立檔案
修改設定
發送訊息
刪除資料

執行一半再停,事情就麻煩了。

所以 Tool Budget 不是寫在 Prompt 裡提醒模型:

請最多使用八次工具

而是由 Host 自己記:

tool_calls_used += len(msg.tool_calls)

限制必須由程式執行。

不能把計數器交給模型。

四、真正跑完整個 Agent Loop

今天的入口反而變得很簡單:

import asyncio

from ironman.agent import print_result, run_agent
from ironman.llm import OllamaClient
from ironman.mcp_bridge import devbench


async def main() -> None:
    async with devbench() as tools, OllamaClient() as llm:
        result = await run_agent(
            llm,
            tools,
        )

        print_result(result)

        assert result.stop_reason == "answered"
        assert result.observations


if __name__ == "__main__":
    asyncio.run(main())

main.py 不再自己處理:

模型呼叫
工具執行
Observation
下一輪
停止條件

全部收進:

run_agent()

裡面。

概念上就是:

for step in range(max_steps):

    response = await llm.chat(
        messages,
        tools=tools.specs,
    )

    messages.append(response.message)

    if response.message.tool_calls:
        # 檢查 Budget
        # 執行 Tool
        # 寫回 Observation
        continue

    # 沒有 Tool Call
    # 有答案 → answered
    # 沒答案 → empty_answer

到這裡,已經有一個真正能反覆使用 MCP Tool 的 Agent。

五、answered 不代表答案一定正確

這點很容易混在一起。

假設最後看到:

stop_reason = answered

它只能證明:

Agent 正常走完流程
模型最後給出答案

不能證明:

答案一定正確

例如 read_file 回傳:

42 | MODEL_NAME = "qwen3"

Tool 回來的第 42 行,是我們真的觀察到的資料。

如果最後模型卻回答:

MODEL_NAME 在第 47 行

那流程還是可以正常 answered,但內容是錯的。

所以我會把兩件事分開:

執行是否成功
≠
答案是否正確

今天先處理前者。

後面的評估再處理後者。

六、測試不只測「Agent 有回答」

這次會確認幾件事:

模型真的呼叫過 Tool
取得過 Observation
最後正常 answered
回答包含來源檔案

還會測一個很重要的邊界:

tool_budget = 0

這種情況下,即使模型想呼叫 Tool,Host 也不能偷偷執行。

所以測試不是只看:

有沒有印出一段文字

而是要看 Agent 實際走過什麼流程。

這些模型相關測試會真的連本機 Ollama,不用固定回覆的 Fake Model 假裝它已經具備工具選擇能力。

七、限制一定要寫在程式裡

今天最值得留下來的一件事,不是 ReAct 的 for 迴圈。

而是:

所有執行限制都必須由 Host 落實。

例如:

最多幾個 Step
最多幾次 Tool Call
哪些 Tool 能用
哪些檔案能讀
一次 Observation 最多多大

這些都不應該只寫成:

請注意不要……
請最多……
請不要讀……

Prompt 可以告訴模型規則。

但真正的限制,還是要有程式碼。

Prompt 是要求
Host 才是執行者

這個觀念後面做安全治理時還會再回來。

八、還有一個問題:Context 會一直長

目前每跑一輪,就會把新的內容繼續加進:

messages

裡面。

user
assistant
tool
assistant
tool
assistant
tool
...

短任務沒什麼問題。

但如果 Agent 持續跑很久,Context 會越來越大。

即使每一次 Tool Result 都有做截斷,也不代表整段對話可以無限保留。

所以之後還會碰到另一個問題:

哪些歷史真的需要留下?

這也是 Session、Memory 與 Context Management 要解決的事情。

今天先不處理。

先把 Agent 的基本生命週期走通。


Day 12 做的是:

模型選 Tool
↓
Action
↓
Observation

今天則把 Observation 放回去:

Question
↓
Model
↓
Action
↓
Observation
↓
Model
↓
...
↓
Answer

到這裡,我們終於有了第一個完整的 ReAct Agent。

而且沒有 Agent Framework。

就是:

Message State
+
LLM
+
MCP Tools
+
Loop
+
Stop Conditions

明天開始加入 LangGraph。

但不是因為今天這個 Agent「不能用」。

而是當流程開始出現更多:

狀態
分支
節點
重試
錯誤處理

一個手寫 for 迴圈會越來越難維護。

Day 14:

ReAct 流程越寫越亂?用一天認識 LangGraph。


上一篇
Day 12:Agent 如何一邊思考一邊使用工具?認識 ReAct
下一篇
Day 14:ReAct 流程越寫越亂?用一天認識 LangGraph
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言